SD35. 취약한 암호화 해쉬함수: 하드코드된 솔트 — 호출마다 새 솔트로 치환

원본 항목: Part 2-3. 암호화 계열 — 16. 취약한 암호화 해쉬함수: 하드코드된 솔트 CWE-326 repo 폴더: sd35_hcsalt/ (빌드·실행은 §3-1) 목표 시간: 1회차 10분 / 2회차 6분 / 3회차 4분 Part 2-3 마지막 드릴이자 암호화 계열의 마지막이다. SD33(솔트 없음)과 SD26(하드코드된 키)이 만나는 지점 — “솔트 자체가 SD26처럼 상수로 박혀 있으면” SD33에서 배운 방어가 전부 무력화된다는 걸 확인한다.

이 드릴의 문서 구성 (SD01~SD58 공통)

① 요구사항서 — 어떤 코드가 뚫려 있고, 무엇을 만드나. ② pseudo code 템플릿 — Bad 와 Good 의 뼈대이고, 회차마다 가린다. ③ 테스트 코드 — 공격은 막히고 정상은 통과해야 끝이다. ④ 자주 하는 실수 ⑤ 모범답안 — 맨 뒤에 접어 둔다.

변형 A/B/C 는 같은 치환 규칙을 다른 소재로 다시 치는 것이다.


0. 이 드릴로 체화할 것

- unsigned get_salt_bad(void) { return 0xC0FFEE; }             /* 항상 같은 솔트 */
+ if (getrandom(raw, sizeof(raw), 0) != (ssize_t)sizeof(raw)) return -1;
+ memcpy(out_salt, raw, sizeof(unsigned));                      /* 호출마다 새 솔트 */

솔트는 비밀이 아니라 “매번 달라야 하는 값”이다. 고정된 순간 솔트로서의 존재 이유가 사라진다.

회차별 진행

회차변형보는 것순서
1회차A 신규 계정 생성 시 솔트 발급① 요구사항서
② pseudo code 전체
1. Bad 를 그대로 쳐서 취약점을 관찰한다.
2. Good pseudo 를 한 줄씩 코드로 바꾼다.
3. ③ 테스트를 실행한다.
2회차B SD33·SD26과 전부 결합① 요구사항서
② pseudo code 의 번호와 제목만 (Level 2)
1. Bad 를 치고 취약점을 확인한다.
2. 번호만 보고 Good 을 쓴다.
3. ③ 테스트를 실행한다.
3회차C 솔트 크기를 16바이트(128비트)로 확장① 요구사항서만1. ③ 테스트를 먼저 읽는다.
2. 백지에서 Bad 부터 Good 까지 재현한다.

1회차에 막히면 같은 변형을 한 번 더 친다. 3회차를 통과하면 A 를 백지로 다시 친다.


1. 요구사항서

1-A. 변형 A — 신규 계정 생성 시 솔트 발급

새 계정을 만들 때마다 그 계정 전용 솔트를 발급한다.

  • 솔트는 매번 달라야 한다.

함수

unsigned get_salt_bad(void);
int get_salt_good(unsigned *out_salt);

공격 입력표

상황Bad 결과이유
계정 100개를 연달아 생성전부 같은 솔트(0xC0FFEE)SD33의 솔트 방어가 사실상 무효화된다 — 모든 계정이 같은 솔트를 쓰면 그 솔트 하나로 만든 레인보우 테이블이 전체 계정에 통한다
같은 상황(Good)계정마다 다른 솔트SD31의 getrandom 을 그대로 재사용

1-B. 변형 B — SD33·SD26과 전부 결합

“계정 생성 시 새 솔트 발급(SD35) → 그 솔트로 패스워드 해시(SD33) → 결과를 저장(SD32)“까지 하나의 함수 체인으로 완성 — Part 2-3 전체를 관통하는 최종 통합 드릴

1-C. 변형 C — 솔트 크기를 16바이트(128비트)로 확장

unsigned(보통 4바이트) 대신 unsigned char[16] 배열로 바꿔 실전 수준의 솔트 크기를 다룬다


2. pseudo code 템플릿 — 보고 쳐서 익힌다

사용법 변형별로 아래 pseudo code 를 소스 파일에 주석만 복사한다. 코드는 주석을 보고 내가 쓴다. 다 쓰면 [ ] 를 x 로 바꾼다. 막히면 §5 모범답안(A)의 해당 단계만 본다.

2-A. get_salt_good — Level 1 (1회차, 전부 본다)

먼저 Bad 를 그대로 쳐서 §3 의 test_bad_is_vulnerable 이 통과하는지(= 실제로 뚫리는지) 확인한다. 그다음 Good 을 쓴다.

unsigned get_salt_bad(void)
{
    /* 1. return 0xC0FFEE   ← 항상 같은 솔트 */
}
 
int get_salt_good(unsigned *out_salt)
{
    /* [ ] 1. 지역변수
     *        unsigned char raw[sizeof(unsigned)] */
 
    /* [ ] 2. getrandom(raw, sizeof(raw), 0) != (ssize_t)sizeof(raw) → return -1 */
 
    /* [ ] 3. memcpy(out_salt, raw, sizeof(unsigned)) */
 
    /* [ ] 4. return 0 */
}

2-B. get_salt_good — Level 2 (2회차, 번호와 제목만 본다)

int get_salt_good(unsigned *out_salt)
{
    /* [ ] 1. 지역변수 */
 
    /* [ ] 2. getrandom(raw, sizeof(raw), 0) != (ssize_t)sizeof(raw) → return -1 */
 
    /* [ ] 3. memcpy(out_salt, raw, sizeof(unsigned)) */
 
    /* [ ] 4. return 0 */
}

2-C. get_salt_good — Level 3 (3회차, 백지)

int get_salt_good(unsigned *out_salt)
{
    /* 아무것도 안 본다.
     * §1 요구사항서와 §0 의 치환 규칙만 보고 친다.
     *
     * 막히면 아래만 찾아본다.
     *   - SD31 의 `gen_token_good` 과 거의 같다
     *   - 솔트는 비밀이 아니라 **매번 달라야 하는 값**
     */
}

막혔을 때 찾는 순서

  1. 컴파일 에러가 나면 에러 문구를 본다. unknown type name / implicit declaration 이면 include 누락이다.
  2. man 3 <함수> 를 연다. SYNOPSIS 맨 위의 #include 를 확인한다.
  3. 로직이 막히면 §3 테스트의 기대값을 먼저 읽는다. 공격 입력표(§1)와 테스트가 답이다.
  4. 그래도 안 되면 모범답안의 해당 단계 하나만 본다.

3. 테스트 코드 — 이게 통과하면 끝

sd35_hcsalt/src/salt_gen.h
sd35_hcsalt/src/salt_gen.c
sd35_hcsalt/test/test.c

시험 구조는 고정이다 (SD01~SD58 공통)

  1. Bad 가 실제로 뚫린다. 이 시험이 실패하면 Bad 를 잘못 옮겨 적은 것이다.
  2. Bad 도 정상 입력은 처리한다.
  3. Good 이 같은 공격을 막는다.
  4. Good 이 정상 입력까지 막지 않는다. (과잉 차단 없음)

3-A. 변형 A 시험 — 이 형태를 고정한다

#include <stdio.h>
#include "salt_gen.h"
 
static int g_fail = 0;
#define T_TRUE(cond, msg) \
    do { if (!(cond)) { g_fail++; \
        printf("  X %s:%d %s\n", __func__, __LINE__, msg); } } while (0)
 
static void test_bad_is_vulnerable(void)
{
    unsigned s1 = get_salt_bad();
    unsigned s2 = get_salt_bad();
    T_TRUE(s1 == s2,
        "Bad는 언제 호출해도 완전히 같은 솔트를 반환해야 한다(레인보우테이블 재사용 가능 = 취약점 재현)");
}
 
static void test_good_produces_fresh_salt(void)
{
    unsigned s1 = 0, s2 = 0, s3 = 0;
    T_TRUE(get_salt_good(&s1) == 0, "첫 번째 호출은 성공해야 한다");
    T_TRUE(get_salt_good(&s2) == 0, "두 번째 호출도 성공해야 한다");
    T_TRUE(get_salt_good(&s3) == 0, "세 번째 호출도 성공해야 한다");
    T_TRUE(!(s1 == s2 && s2 == s3),
        "Good은 호출마다 매번 새 솔트를 만들어야 한다(세 값이 전부 같을 확률은 무시 가능)");
}
 
int main(void)
{
    test_bad_is_vulnerable();
    test_good_produces_fresh_salt();
    printf(g_fail ? "FAIL %d\n" : "PASS\n", g_fail);
    return g_fail ? 1 : 0;
}

3-B · 3-C. 변형 B / C 시험

3-A 와 같은 4종 구조에서 호출 함수와 입력만 바꾼다.

변형 B — SD33·SD26과 전부 결합

입력BadGood
계정 생성 → 솔트 → 해시 → 저장고정 솔트계정마다 다른 솔트로 해시
두 계정의 같은 비밀번호같은 해시다른 해시

변형 C — 솔트 크기를 16바이트(128비트)로 확장

입력BadGood
unsigned char salt[16]4바이트 고정16바이트 난수
연속 두 번 호출같은 값서로 다르다

3-1. 빌드와 실행 (Makefile, gcc)

Makefile 은 템플릿 체화 드릴 README 와 같은 파일이다. recipe 줄은 반드시 탭으로 들여쓴다.

실행 명령이다.

make D=sd35_hcsalt V=main SRC="sd35_hcsalt/src/salt_gen.c sd35_hcsalt/test/test.c"
  • 마지막 줄이 PASS 이고 경고 0 이면 통과다.
  • -Werror 때문에 컴파일이 안 되면 테스트 이전에 코드를 고친다.

4. 자주 하는 실수

실수왜 문제인가
”솔트 하드코딩”과 “키 하드코딩”(SD26)을 같은 문제로 뭉뚱그림키는 비밀이라 저장소에서 안전하게 보관해야 하지만, 솔트는 비밀이 아니라 매번 새로 만들어야 하는 값이다.
대응 방식이 다르다(SD26=키 저장소 조회, SD35=매번 신규 생성)
솔트를 rand() 로 생성(SD31 이전 방식으로 회귀)솔트도 예측 가능하면 사전 계산 공격의 여지가 생긴다.
SD31에서 이미 확립한 getrandom 패턴을 그대로 가져온다
Part 2-3을 여기서 끝내고 SD32·33·35를 하나로 잇는 변형 B를 건너뜀실전에서는 이 세 항목이 한 함수 안에서 동시에 지켜져야 한다.
따로따로는 이해했지만 합쳐서 짜보지 않으면 실무에서 순서를 놓치기 쉽다

5. 모범답안 (변형 A)


관련 노트